翻译—-Pseudorandom Noise-Noise Variants
如果你觉得这篇教程不错,请去支持原作者
此教程使用的Unity版本为2020.3.12f1
组合多个音阶(Octaves)来创建一个分析噪声
引入扰动型(turbulence)柏林和值噪声
增加一个选项来创建瓦片效果
这是伪随机噪声系列教程的第五篇.增加分形噪声,扰动型噪声和瓦片型布局.
1 分形噪声(Fractal Noise) 到目前为止,我们只使用了一个单一的柏林噪声或值噪声.虽然结果看起来是随机的,但是有很多大小相同的特征块.就算有变化,那也是基于同一个晶格.所有的变化都仅限于单一的缩放等级上,并且由域变换决定的.噪声在大幅度和小幅度上变化太少,轻易暴露了它是人为的本质.
我们可以在不同的缩放等级上再次对噪声进行采样,在第二个频率上引入变化.最简单的方法是,在缩放为1倍和2倍上分别采样.把两个采样值相加得到新的噪声,便得到了有大有小的变化.我们可以多来几次,把下一个较大倍率的采样加上较小的特征.最终的结果会因为小特征(微观)与大特征(宏观)相似而出现自相似性,所以被称为分形噪声.
1.1 噪声的设置(Noise Settings) 想要支持分形噪声,我们需要增加一些配置选项来控制它.为了便于将配置传递给Noise ,先创建一个public结构体Noise.Settings ,当前只包含一个整数字段seed.由于这个结构纯粹是为了方便使用配置选项,就直接把这个字段公开,并用System.Serializable 属性把这个结构体标记为可序列化.这样,Unity就可以保存配置信息,方便我们使用.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 using System;using Unity.Burst;… public static partial class Noise { [Serializable] public struct Settings { public int seed; } … }
结构体里的字段必须在使用前全部初始化.目前我们先添加一个静态方法Default
来方便使用,初始化数值后面在弄.
1 2 3 4 5 6 public struct Settings { public int seed; public static Settings Default => new Settings {}; }
修改Noise.Job 代码用Settings 作为参数代替seed和hash.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public Settings settings;public float3x4 domainTRS;public void Execute (int i) { var hash = SmallXXHash4.Seed(settings.seed); noise[i] = default (N).GetNoise4(domainTRS.TransformVectors(transpose(positions[i])), hash); } public static JobHandle ScheduleParallel ( NativeArray<float3x4> positions, NativeArray<float4> noise, Settings settings, SpaceTRS domainTRS, int resolution, JobHandle dependency) => new Job<N> { positions = positions, noise = noise, settings = settings, domainTRS = domainTRS.Matrix, }.ScheduleParallel(positions.Length, resolution, dependency);
同样修改一下ScheduleParallel
的代码.
1 2 3 4 public delegate JobHandle ScheduleDelegate ( NativeArray<float3x4> positions, NativeArray<float4> noise, Settings settings, SpaceTRS trs, int resolution, JobHandle dependency ) ;
最后修改下NoiseVisualization 类里的代码,用Settings 来代替seed .
1 2 3 4 5 6 7 8 9 10 11 [SerializeField] Settings noiseSettings = Settings.Default; … protected override void UpdateVisualization (NativeArray<float3x4> positions, int resolution, JobHandle handle) { noiseJobs[(int )type, dimensions - 1 ](positions, noise, noiseSettings, domain, resolution, handle).Complete(); noiseBuffer.SetData(noise); }
1.2 频率(Frequency) 目前我们仍然只对每个点进行一次噪声采样.图案的缩放是由域缩放控制的.这个缩放也被称为噪声的频率,它体现出噪声变化的快慢.频率或缩放值越高,它的变化越快,因此其特征越小.
我们为方便控制频率,添加一个Settings 字段,并把频率限制为int整数,原因稍后会说明.频率必须是正数,并且最小为1,使用属性Min 来限制范围,目前默认为4.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 … using UnityEngine;using static Unity.Mathematics.math;public static partial class Noise { [Serializable] public struct Settings { public int seed; [Min(1 )] public int frequency; public static Settings Default => new Settings { frequency = 4 }; } … }
在Noise.Job 中的Execute
方法中使用frequency 对position 进行缩放.
1 2 3 4 5 6 7 public void Execute (int i) { float4x3 position = domainTRS.TransformVectors(transpose(positions[i])); var hash = SmallXXHash4.Seed(settings.seed); int frequency = settings.frequency; noise[i] = default (N).GetNoise4(frequency * position, hash); }
从现在开始,频率和域的缩放都可以影响噪声.频率的缩放是等比的,而域的缩放可以是不等比的,甚至还能是负的.
1.3 音阶(Octaves) 分形噪声由不同频率的多个样本组成.这些被称为Octaves .完美的分形噪声有无限多的Octaves ,但我们必一一计算每一个,所以顶多只能支持几个.Octaves 数越多,噪声的细节就越多,但生成所需要的时间也更长.所以我们添加一个整数字段来控制Octaves 的个数.这里至少应该有一个Octaves ,合理的最大值是6,1是个不错的默认值.
1 2 3 4 5 6 7 8 [Range(1 , 6 )] public int octaves;public static Settings Default => new Settings{ frequency = 4 , octaves = 1 };
现在修改下Noise.Job.Execute
,做一个octaves的循环,每次循环调用GetNoise4
,然后将频率翻倍.把所有的采样加在一起,作为最终的噪声值.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public void Execute (int i) { float4x3 position = domainTRS.TransformVectors(transpose(positions[i])); var hash = SmallXXHash4.Seed(settings.seed); int frequency = settings.frequency; float4 sum = 0f ; for (int o = 0 ; o < settings.octaves; o++) { sum += default (N).GetNoise4(frequency * position, hash); frequency *= 2 ; } noise[i] = sum; }
这里使用值噪声的截图来演示结果,因为它生成的块状octaves 图案比柏林噪声的更容易看清楚.
One, two, and three octaves of 2D value noise.
当我们把相同强度的多个octaves 相加后,频率较高的将在计算结果中占主导地位.但是分形噪声的定义是,一个octaves 的振幅应该随着其频率的增加而减少.因此,每当我们把频率放大一倍,同时也应该把噪声的振幅减半.
1 2 3 4 5 6 7 8 9 10 int frequency = settings.frequency;float amplitude = 1f ;float4 sum = 0f ; for (int o = 0 ; o < settings.octaves; o++){ sum += amplitude * default (N).GetNoise4(frequency * position, hash); frequency *= 2 ; amplitude *= 0.5f ; }
此外,多个octaves 相加产生的噪声会超过[-1,1].所以我们应该将结果标准化,用octaves 和除以振幅之和.
1 2 3 4 5 6 7 8 9 10 11 12 float amplitude = 1f , amplitudeSum = 0f ;float4 sum = 0f ; for (int o = 0 ; o < settings.octaves; o++){ sum += amplitude * default (N).GetNoise4(frequency * position, hash); amplitudeSum += amplitude; frequency *= 2 ; amplitude *= 0.5f ; } noise[i] = sum / amplitudeSum;
One, two, and three octaves with decreasing amplitude and normalization.
1.4 给每个音阶分配不同的种子(Unique Seeds Per Octave) 我们目前把不同缩放系数的噪声叠加到了一起.看到了一个奇怪的现象,在域的原点坍缩成了一个奇点.明显感觉到每个缩放等级都有重复的特征,并且收敛在原点处.
我们可以让octaves 使用不同的hash值来消除这种视觉假象.继续累加SmallXXHash4 的accumulator 字段就行,这样可以有效地对每个octaves 使用连续的种子.
在SmallXXHash4 增加一个+运算符的静态方法.直接向accumulator 累加一个整数,就可以产生不同的hash值了.
1 public static SmallXXHash4 operator + (SmallXXHash4 h, int v) => h.accumulator + (uint)v;
然后在Noise.Job.Execute
的octaves 循环中把迭代器的值传给GetNoise4
,加到hash后面.
1 sum += amplitude * default (N).GetNoise4(frequency * position, hash + o);
1.5 间隙度(Lacunarity) 我们并不总是需要把各个连续的octaves 的频率翻倍.频率的缩放比被称为噪声的间隙度.在Settings 中添加一个lacunarity的int字段,范围限制在[2,4],默认为2.
1 2 3 4 5 6 7 8 9 [Range(2 , 4 )] public int lacunarity;public static Settings Default => new Settings{ frequency = 4 , octaves = 1 , lacunarity = 2 };
间隙度(lacunarity)是什么意思?
在当前的项目中,lacunarity是对分形如何填充空间的一种几何描述.lacunarity越高,octaves之间的缝隙越多,或者空白空间越大.它源于拉丁语的lacuna,意思是缝隙(gap)或湖泊(lake).
在Noise.Job.Execut
e中使用lacunarity 它来缩放频率,而不总是将频率翻倍.
1 2 3 4 sum += amplitude * default (N).GetNoise4(frequency * position, hash + o); frequency *= settings.lacunarity; amplitude *= 0.5f ; amplitudeSum += amplitude;
Lacunarity 2, 3, and 4; frequency 2 with three octaves.
1.6 韧性(Persistence) 就像我们的lacunarity 是可以动态配置的,而不是永远都是2一样.octaves 之间的振幅也要设计成可配置模式,而不是一直都是0.5.这个因子被称为韧性(Persistence ),用来表达幅度在每个octaves 间的衰减速度.它是一个float型数据,范围为[0,1].把它加到Settings 中然后默认值为0.5.
1 2 3 4 5 6 7 8 9 10 [Range(0f , 1f )] public float persistence;public static Settings Default => new Settings{ frequency = 4 , octaves = 1 , lacunarity = 2 , persistence = 0.5f };
在Noise.Job.Execute
中应用persistence 的数据,而不是硬编码为0.5.
1 2 frequency *= settings.lacunarity; amplitude *= settings.persistence;
Persistence 0.25, 0.5, and 0.75; frequency 4 with 3 octaves.
2 扰动(Turbulence) 一个常见的分析噪声变体是把各个音阶的绝对值相加.这会让音阶在通过0点时反弹,形成一个折痕.叠加多个这样音阶的效果被Ken Perlin成为扰动模式,因此这通常被称为柏林噪声的扰动变体.
2.1 Evaluation After Interpolation 为了得到一个音阶的绝对值,需要在插值后对梯度噪声进行进一步计算.目前我们可以把这个操作按梯度算法归纳为一个通用的流程.所以向IGradient 接口中添加一个声明EvaluateAfterInterpolation
,参数为一个向量化的噪声,在函数中对其进行评估以得到最后的结果.
1 2 3 4 5 6 7 8 9 10 public interface IGradient{ float4 Evaluate (SmallXXHash4 hash, float4 x) ; float4 Evaluate (SmallXXHash4 hash, float4 x, float4 y) ; float4 Evaluate (SmallXXHash4 hash, float4 x, float4 y, float4 z) ; float4 EvaluateAfterInterpolation (float4 value) ; }
常规的值,柏林梯度函数的实现保持不变,不过需要实现这个新的方法,目前简单地返回参数.
1 2 3 4 5 6 7 8 9 10 11 12 13 public struct Value : IGradient{ … public float4 EvaluateAfterInterpolation (float4 value) => value; } public struct Perlin : IGradient{ … public float4 EvaluateAfterInterpolation (float4 value) => value; }
为了用上这个函数,在GetNoise4
中,把插值结果变成此函数的参数传入.Lattice1D ,Lattice2D 和Lattice3D 类中都要改.
1 2 3 4 var g = default (G); return g.EvaluateAfterInterpolation(lerp( … ));
2.2 泛型扰动(Generic Turbulence) 现在我们可以通过复制柏林噪声和值噪声的代码,并修改它们的EvaluateAfterInterpolation
函数,以返回绝对值来创建扰动型变体.然而,与其重复写两次这样的代码,不如给Noise.Gradient 引入一个通用的泛型结构,包裹一个任意的梯度类型.它将所有的方法调用转发给通用梯度,只是需要使用abs
方法将最终的结果变为绝对值.
1 2 3 4 5 6 7 8 9 10 public struct Turbulence < G> : IGradient where G : IGradient{ public float4 Evaluate (SmallXXHash4 hash, float4 x) => default (G).Evaluate(hash, x); public float4 Evaluate (SmallXXHash4 hash, float4 x, float4 y) => default (G).Evaluate(hash, x, y); public float4 Evaluate (SmallXXHash4 hash, float4 x, float4 y, float4 z) => default (G).Evaluate(hash, x, y, z); public float4 EvaluateAfterInterpolation (float4 value) => abs (default (G).EvaluateAfterInterpolation(value)); }
2.3 Turbulence Perlin and Value Noise 我们现在可以添加扰动型变体到NoiseVisualization 类中,指明Turbulence<Perlin> 和Turbulence<Value> 为变体的类型参数.并添加到Job数组中作为选项.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 static ScheduleDelegate[,] noiseJobs ={ { Job<Lattice1D<Perlin>>.ScheduleParallel, Job<Lattice2D<Perlin>>.ScheduleParallel, Job<Lattice3D<Perlin>>.ScheduleParallel }, { Job<Lattice1D<Turbulence<Perlin>>>.ScheduleParallel, Job<Lattice2D<Turbulence<Perlin>>>.ScheduleParallel, Job<Lattice3D<Turbulence<Perlin>>>.ScheduleParallel }, { Job<Lattice1D<Value>>.ScheduleParallel, Job<Lattice2D<Value>>.ScheduleParallel, Job<Lattice3D<Value>>.ScheduleParallel }, { Job<Lattice1D<Turbulence<Value>>>.ScheduleParallel, Job<Lattice2D<Turbulence<Value>>>.ScheduleParallel, Job<Lattice3D<Turbulence<Value>>>.ScheduleParallel } };
然后把噪声类型信息也添加到NoiseType 枚举信息中.
1 public enum NoiseType { Perlin, PerlinTurbulence, Value, ValueTurbulence }
Regular Perlin and value noise, and their turbulence variants.
因为绝对值永远不会是负的,所以扰动效果总是灰度模式,并且不以零点为中心.正的偏移与负的偏移看起来完全不同.正的有较圆的波峰,较窄的波谷,而负的有尖锐的波峰,较宽的波谷.相比之下,普通噪声的正负偏移起来很相似,只是正负梯度颜色的朝向不同.
Sphere with 0.2 and −0.2 displacement, both regular 3D Perlin and turbulence variant.
3 瓦片噪声(Tiling Noise) 另一个很有用的噪声变体是创建重复的图案.不过不是为了直接填充一个大区域,而是生成一个个小的纹理或网格来无缝铺满一个大的区域.
想要平铺一个图案,相邻的采样区的样子必须是相同的.由于我们使用的是晶格式网格,可以通过重复相同的跨度来做,让这个序列的长度等于噪声的频率,所以,在任何维度上,频率为4的噪声将每四个跨度重复一次.
下一步,频率和间隙度也必须始终是整数,这就是为什么我们把它们变成int类型.
3.1 Frequency at the Lattice Level 为了使实现重复的效果,我们必须将频率传递给函数INoise.GetNoise4
.
1 2 3 4 public interface INoise{ float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) ; }
我们现在准备在较低的层面上应用这个频率,在Noise.Job.Evaluate
中分别传入未修改的位置和频率参数,而不是用频率缩放位置.
1 sum += amplitude * default (N).GetNoise4(position, hash + o, frequency);
晶格点是由GetLatticeSpan4
函数计算得来的,因此向它添加一个频率参数,并在该函数最开始对频率进行缩放.
1 2 3 4 5 6 static LatticeSpan4 GetLatticeSpan4 (float4 coordinates, int frequency) { coordinates *= frequency; float4 points = floor (coordinates); … }
最后修改一下所有Lattice 类的GetNoise4
函数里的频率参数.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 public struct Lattice1D < G> : INoise where G : struct , IGradient { public float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) { LatticeSpan4 x = GetLatticeSpan4(positions.c0, frequency); … } } public struct Lattice2D < G> : INoise where G : struct , IGradient { public float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) { LatticeSpan4 x = GetLatticeSpan4(positions.c0, frequency), z = GetLatticeSpan4(positions.c2, frequency); … } } public struct Lattice3D < G> : INoise where G : struct , IGradient { public float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) { LatticeSpan4 x = GetLatticeSpan4(positions.c0, frequency), y = GetLatticeSpan4(positions.c1, frequency), z = GetLatticeSpan4(positions.c2, frequency); … } }
3.2 Lattice接口(Lattice Interface) 为了同时支持普通和瓦片类型,需要在Noise.Lattice 类中加入一个新的ILattice 接口,里面添加一个GetLatticeSpan4
函数声明.LatticeSpan4 结构体也需要改成public,因为接口的访问权限是public.
1 2 3 4 5 6 public struct LatticeSpan4 { … }public interface ILattice{ LatticeSpan4 GetLatticeSpan4 (float4 coordinates, int frequency) ; }
现在写一个继承了ILattice 接口的LatticNormal 类,并实现其中的方法.
1 2 3 4 5 6 7 public struct LatticeNormal : ILattice{ public LatticeSpan4 GetLatticeSpan4 (float4 coordinates, int frequency) { … } }
然后修改Lattice 这一系列模板类,使用ILattice 作为类型参数约束来获得具体的跨度算法.这意味着现在有了两个模板参数.记得泛型类型约束是一个接一个地写的.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 public struct Lattice1D < L, G> : INoise where L : struct , ILattice where G : struct , IGradient { public float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) { LatticeSpan4 x = default (L).GetLatticeSpan4(positions.c0, frequency); … } } public struct Lattice2D < L, G> : INoise where L : struct , ILattice where G : struct , IGradient { public float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) { var l = default (L); LatticeSpan4 x = l.GetLatticeSpan4(positions.c0, frequency), z = l.GetLatticeSpan4(positions.c2, frequency); … } } public struct Lattice3D < L, G> : INoise where L : struct , ILattice where G : struct , IGradient { public float4 GetNoise4 (float4x3 positions, SmallXXHash4 hash, int frequency) { var l = default (L); LatticeSpan4 x = l.GetLatticeSpan4(positions.c0, frequency), y = l.GetLatticeSpan4(positions.c1, frequency), z = l.GetLatticeSpan4(positions.c2, frequency); … } }
3.3 瓦片(Tiling) 为了创建一个平铺网格,复制一份LatticeNormal 的代码并将其重命名为LatticeTiling ,然后修改下它的GetLatticeSpan4
函数,让晶格点在频率处重复跨度.可以通过余数或模操作%,取点的余数除以频率来实现,但是必须在基本梯度值之后进行处理.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public struct LatticeTiling : ILattice{ public LatticeSpan4 GetLatticeSpan4 (float4 coordinates, int frequency) { coordinates *= frequency; float4 points = floor (coordinates); LatticeSpan4 span; span.p0 = (int4)points; span.p1 = span.p0 + 1 ; span.g0 = coordinates - span.p0; span.g1 = span.g0 - 1f ; span.p0 %= frequency; span.p1 %= frequency; span.t = coordinates - points; span.t = span.t * span.t * span.t * (span.t * (span.t * 6f - 15f ) + 10f ); return span; } }
现在可以添加支持所有噪声的平铺版本,只需要在NoiseVisualization 的数组中声明就行了.我只展示了柏林噪声的.
1 2 3 4 5 6 7 8 { Job<Lattice1D<LatticeNormal, Perlin>>.ScheduleParallel, Job<Lattice1D<LatticeTiling, Perlin>>.ScheduleParallel, Job<Lattice2D<LatticeNormal, Perlin>>.ScheduleParallel, Job<Lattice2D<LatticeTiling, Perlin>>.ScheduleParallel, Job<Lattice3D<LatticeNormal, Perlin>>.ScheduleParallel, Job<Lattice3D<LatticeTiling, Perlin>>.ScheduleParallel },
我们将使用一个bool字段来切换标准和平铺功能,而不是将我们的下拉列表的扩大一倍.第二个数组索引等于两倍维度减去1(平铺),或者2(非平铺).
1 2 3 4 5 6 7 8 9 10 11 12 [SerializeField] bool tiling;… protected override void UpdateVisualization (NativeArray<float3x4> positions, int resolution, JobHandle handle) { noiseJobs[(int )type, 2 * dimensions - (tiling ? 1 : 2 )]( positions, noise, noiseSettings, domain, resolution, handle ).Complete(); noiseBuffer.SetData(noise); }
因为单个瓦片会填满整个单位面积,如果我们想要看到平铺效果,必须并增加域的缩放,才能看到非常明显的重复.
虽然现在有平铺的现象,但这是不对的,因为目前的效果取决于维度的符号.因此,二维噪声显示了四种不同的图案.出现这种情况是因为余数受到了正负号的影响.为了解决这个问题,我们必须修改下LatticeTiling.GetLatticeSpan4
函数.
首先检查第一个点,先判断计算后其余数是否为负数,如果是,方向就错了,我们可以通过在该点上加大频率来解决这个问题.
1 2 3 span.p0 %= frequency; span.p0 = select(span.p0, span.p0 + frequency, span.p0 < 0 ); span.p1 %= frequency;
我们还需要修正第二个点,因为它总是比第一个点在正方向上远一个单位,所以可以基于第一个点来计算.
1 2 3 4 5 6 7 span.g0 = coordinates - span.p0; span.g1 = span.g0 - 1f ; span.p0 %= frequency; span.p0 = select(span.p0, span.p0 + frequency, span.p0 < 0 ); span.p1 = (span.p0 + 1 ) % frequency;
Correct tiling, one and three octaves.
平铺噪声的主要用途是创建无缝连接的纹理或网格.它还可以通过采样多个3D噪声的2D切片来创建循环2D动画.
Four times the same noise sample, domain scale 1.
是否可以只在一部分维度上平铺,而不是所有
可以,通过在每个维度上引入一个单独的通用晶格参数.所以3D噪声就有三种重复的晶格类型.为了使教程的简单,我将其设置为“要么全有,要么全无”.
3.4 向量化的瓦片(Vectorized Tiling) 整数求余是通过整数除法完成的,而整数除法并没有向量化.因此,瓦片噪声的计算需要对点的数据进行重复利用.先计算每个单点的余数,然后再计算一次,这样做效率很低,而且没有必要.
观察一下跨度的第二个点,我们根本不需要计算这个点的余数.可以通过在第一点上加1,然后检查它是否与频率相等来得到同样的结果.如果频率相等,就开始重复图案.因为图案总是从0点重新开始的.
1 2 3 span.p1 = span.p0 + 1 ; span.p1 = select(span.p1, 0 , span.p1 == frequency);
不过第一个点的余数是必须要算的,但是可以通过浮点数 除法来实现,浮点数除法是向量化的,只需要延迟一步转换为整数就行.余数的计算方法是:将原浮点坐标,除以频率,接着强转为整数,再与频率相乘,最后用p0减去它.
1 2 3 span.p0 -= (int4)(points / frequency) * frequency; span.p0 = select(span.p0, span.p0 + frequency, span.p0 < 0 );
原文授权协议
原文项目仓库地址
我写的单CPU端萌新入坑版项目地址